forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { NpmDownloadCount } from '#shared/types'
2import {
3 CACHE_MAX_AGE_FIVE_MINUTES,
4 ERROR_NPM_FETCH_FAILED,
5 NPM_API,
6} from '#shared/utils/constants'
7import { encodePackageName } from '#shared/utils/npm'
8
9/**
10 * Returns lightweight package metadata for search results.
11 *
12 * Fetches the full packument + weekly downloads server-side, extracts only
13 * the fields needed for package cards, and returns a small JSON payload.
14 * This avoids sending the full packument (which can be MBs) to the client.
15 *
16 * URL patterns:
17 * - /api/registry/package-meta/packageName
18 * - /api/registry/package-meta/@scope/packageName
19 */
20export default defineCachedEventHandler(
21 async event => {
22 const pkgParam = getRouterParam(event, 'pkg')
23 if (!pkgParam) {
24 throw createError({ statusCode: 404, message: 'Package name is required' })
25 }
26
27 const packageName = decodeURIComponent(pkgParam)
28 const encodedName = encodePackageName(packageName)
29
30 try {
31 const [packument, downloads] = await Promise.all([
32 fetchNpmPackage(packageName),
33 $fetch<NpmDownloadCount>(`${NPM_API}/downloads/point/last-week/${encodedName}`).catch(
34 () => null,
35 ),
36 ])
37
38 const latestVersion =
39 packument['dist-tags']?.latest || Object.values(packument['dist-tags'] ?? {})[0] || ''
40 const modified = packument.time?.modified || packument.time?.[latestVersion] || ''
41 const date = packument.time?.[latestVersion] || modified
42
43 // Extract repository URL from the packument's repository field
44 // TODO: @npm/types says repository is always an object, but some old
45 // packages have a bare string in the registry JSON
46 let repositoryUrl: string | undefined
47 if (packument.repository) {
48 const repo = packument.repository as { url?: string } | string
49 const rawUrl = typeof repo === 'string' ? repo : repo.url
50 if (rawUrl) {
51 // Normalize git+https:// and git:// URLs to https://
52 repositoryUrl = rawUrl
53 .replace(/^git\+/, '')
54 .replace(/^git:\/\//, 'https://')
55 .replace(/\.git$/, '')
56 }
57 }
58
59 // Extract bugs URL
60 // TODO: @npm/types types bugs as { email?: string; url?: string } on
61 // packuments, but some old packages store it as a plain URL string
62 let bugsUrl: string | undefined
63 if (packument.bugs) {
64 const bugs = packument.bugs as { url?: string } | string
65 bugsUrl = typeof bugs === 'string' ? bugs : bugs.url
66 }
67
68 // Normalize author field to NpmPerson shape
69 // TODO: @npm/types types author as Contact (object), but some old
70 // packages store it as a plain string (e.g. "Name <email>")
71 let author: { name?: string; email?: string; url?: string } | undefined
72 if (packument.author) {
73 const a = packument.author as { name?: string; email?: string; url?: string } | string
74 author = typeof a === 'string' ? { name: a } : { name: a.name, email: a.email, url: a.url }
75 }
76
77 // Normalize license to a string
78 // TODO: @npm/types types license as string, but some old packages use
79 // the deprecated { type, url } object format
80 const license = packument.license
81 ? typeof packument.license === 'string'
82 ? packument.license
83 : (packument.license as { type: string }).type
84 : undefined
85
86 return {
87 name: packument.name,
88 version: latestVersion,
89 description: packument.description,
90 keywords: packument.keywords,
91 license,
92 date,
93 links: {
94 npm: `https://www.npmjs.com/package/${packument.name}`,
95 homepage: packument.homepage,
96 repository: repositoryUrl,
97 bugs: bugsUrl,
98 },
99 author,
100 maintainers: packument.maintainers,
101 weeklyDownloads: downloads?.downloads,
102 }
103 } catch (error: unknown) {
104 handleApiError(error, {
105 statusCode: 502,
106 message: ERROR_NPM_FETCH_FAILED,
107 })
108 }
109 },
110 {
111 maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
112 swr: true,
113 getKey: event => {
114 const pkg = getRouterParam(event, 'pkg') ?? ''
115 return `package-meta:v1:${pkg}`
116 },
117 },
118)